Skip to content

feat(Builder): a merger registry, and one entry per key in the Specification - #2220

Merged
DerManoMann merged 3 commits into
zircote:masterfrom
DerManoMann:feat/merge-pass
Oct 2, 2026
Merged

DerManoMann merged 3 commits into
zircote:masterfrom
DerManoMann:feat/merge-pass

Conversation

@DerManoMann

@DerManoMann DerManoMann commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Overview

Two attributes can claim one key. Two operations on the same path and method, two schemas named Pet — a scan finds one, a withSpecification() hook contributes the other, an inheritance clone makes a third. Nothing decided between them, so the compiler decided by accident: it writes each into a PHP array and keeps whichever it wrote last, silently. Classic reports the same collision as an error.

It was not even one rule. compilePaths() writes operations with = and folds path items in with +, so the last operation for a path and method won while the first path item for a path did. One map, one build, opposite rules, nothing reported either way. Webhooks were the quietest case: same last-wins, and no diagnostic of any kind, since a webhook operation gets no operationId and so never trips the accidental uniqueness warning that operations happen to have.

This gives the decision an owner. A merger says what makes two attributes the same one and which survives; the pass applies the chain to the Specification's own collections and writes one entry per key back, so what reaches the compiler has no collisions left and the compiler's incidental rules stop deciding anything. The shipped merger keeps the later entry and says so, which is what was happening anyway — now stated once, for every collection with a key, and reported.

Only the root collections. That is where the halves come from different places and something has to choose. Two entries inside one attribute were written in one place by one author, so the compiler keeps the last of them as it always has.

Warnings a spec build produced were also being dropped: Result carried the compiler's diagnostics and nothing the pipeline said, so a merger's report would have gone nowhere.

Changes

  • Contracts\MergerInterface — supports(), identity(), merge(); a chain, first match wins, the shape ResolverInterface has
  • Augmenter\Merge — the pass, first in the reduce phase and again as the last default pipe
  • Merge\LastWins — the catch-all, keying each root collection the way the document does and reporting a collision with both locations
  • Builder::withMergers() and getDefaultMergers(), ordered by Utils\TypedList like the augmenters
  • getMeta()/setMeta() on every attribute: a keyed store for whoever extends swagger-php, so a contributing package can mark its own attributes and have its merger recognise them; nothing in src/ reads or writes it, and a test keeps it that way
  • Utils\Pipeline takes a logger after construction
  • Builder collects what the spec pipeline logs into Result
  • compilePaths() documents what its union still decides now that duplicate path items are gone
  • Mergers documented in the extension points guide and the Builder reference; the reference pages list the shipped merger and the new pipe

One output change: a PathItem against a PathItem for one path moves from first-wins to last-wins, the same rule as everything else. Nothing can have relied on it, since neither behaviour was documented or reported.

@DerManoMann
DerManoMann force-pushed the feat/merge-pass branch 2 times, most recently from ac28b89 to 10a431b Compare September 30, 2026 00:05
@DerManoMann
DerManoMann marked this pull request as ready for review September 30, 2026 00:19
`setLogger()` via `LoggerAwareInterface`, so a logger can be handed over after
the pipeline was built — the builder has the build's logger only by then, and a
`setLogger()` call after `withAugmenters()` used to leave the pipeline on the
null logger.
…ication

Two attributes claiming one key reached the compiler together, which wrote both
into a PHP array and kept whichever it wrote last. Nothing said so outside the
component buckets, and it was not even one rule: path items folded with `+`, so
for those the *first* won.

`Augmenter\Merge` decides instead. It groups each root collection on the
claiming merger's `identity()`, folds each group in producer order and writes one
entry per key back, so the compiler never sees a collision. It runs first in the
reduce phase, the earliest point every identity exists, and again as the last
default pipe, for what a late augmenter added.

`Merge\LastWins` is the catch-all: it claims every type, keys each collection
the way the document does — component key, path and method, webhook and method,
path, tag name — and on a collision keeps the later entry and warns naming both
halves. Positional lists have no key and pass through.

`Builder::withMergers()` configures the chain, the fourth default the builder
holds through a hook of its own.

Every attribute gets `getMeta()`/`setMeta()`, a keyed store for whoever extends
swagger-php: a package contributing through a hook marks its own attributes and
its merger reads the mark back to decide precedence. Nothing in `src/` writes or
reads it, and a test keeps it that way.

A spec build now collects what its pipeline logs, so a merger's warning reaches
`Result`; the compiler keeps its own logger and nothing is collected twice.

One output change: a `PathItem` against a `PathItem` for one path moves from
first-wins to last-wins, the same rule as everything else.

A collision inside one attribute is left alone: those halves were written in one
place by one author, and the compiler keeps the last of them as it always has.
@DerManoMann
DerManoMann merged commit 6dcdc1d into zircote:master Oct 2, 2026
19 checks passed
@DerManoMann
DerManoMann deleted the feat/merge-pass branch October 2, 2026 02:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant